Skip to main content

06 - 前缀缓存与排布

前置030405 篇。那三篇里的每一招都会动到请求前缀。

本篇回答:为什么"token 省下来了、账单反而涨了",以及内容该按什么顺序排。

本篇会用到的词

意思
前缀匹配缓存按请求开头的字节序列匹配。前缀里任何一个字节变了,从那里往后全部要重算
渲染顺序请求拼成 token 序列的固定顺序:toolssystemmessages
断点(breakpoint)cache_control 标记,告诉服务在这个位置之前的内容做缓存。每次请求最多 4 个
缓存写入 / 读取首次建立缓存的价格约是常规输入的 1.25 倍,命中读取约 0.1 倍
会话中系统消息把操作指令作为 {"role": "system"} 追加进 messages,而不是改顶层 system

一、机制:一个字节定生死

同样是改 300 个 token,改的位置决定要重算 300 还是 180,000渲染顺序toolssystemmessages(对话历史)本轮改 tools全部重算改 systemsystem 之后全部重算改中间某轮该轮之后重算只加本轮前面全部命中缓存 —— 这是正常状态深色部分=要按完整价格重新计算的 token。裁剪和压缩改的都是中间那一段,所以它们必然打掉一部分缓存。
渲染顺序是固定的,你唯一能决定的是把什么放进哪一段。原则由此直接推出:越是每轮都在变的东西,越要往后放。

价格结构决定了这件事有多重要:

类型相对常规输入的价格
缓存读取(命中)约 0.1 倍
缓存写入(首次建立)约 1.25 倍
未缓存1 倍

也就是说,一次缓存失效的代价,相当于把那部分内容按 12.5 倍于命中价重付一次

二、断点放哪

最多 4 个 cache_control 断点。三种典型放置:

// 模式 A:大系统提示词,多个请求共享 —— 断点放最后一个 system 文本块
// tools 渲染在 system 之前,所以这一个断点把 tools + system 一起缓存了
system: [{ type: "text", text: LARGE_SYSTEM, cache_control: { type: "ephemeral" } }]

// 模式 B:多轮对话 —— 断点放最新一轮的最后一个内容块
// 每次请求复用之前全部前缀;更早的断点仍是有效读取点,命中随对话增长而累积
messages[messages.length - 1].content.at(-1).cache_control = { type: "ephemeral" };

// 模式 C:共享前缀 + 变化后缀(少样本示例、检索文档 + 不同问题)
// ⚠️ 断点要放在【共享部分】的末尾,不是整个提示词的末尾。
// 放在末尾的话,每个请求都会写入一份互不相同的缓存条目,永远读不到。

两个容易忽略的限制:

  • 最小可缓存前缀约 1,024 token(随模型不同)。比这短的前缀即使标了断点也不会缓存,不报错,只是 cache_creation_input_tokens 一直是 0
  • 默认 TTL 5 分钟,可以设 ttl: "1h"。用户思考几分钟再发下一句,就已经过期了 —— 交互式产品要考虑这一条

三、六类静默失效

这些全都不报错。审计时直接对着这张表 grep 喂进前缀的那些代码:

模式为什么破缓存grep 什么
系统提示词里有当前时间每次请求前缀都不同datetime.now() / Date.now() / time.time()
靠前的位置有请求 ID 或 UUID同上uuid4() / crypto.randomUUID()
JSON 序列化没固定键顺序字节序列不确定json.dumps( 没带 sort_keys=True;遍历 set
系统提示词里插了用户 ID每个用户一份前缀,跨用户完全不共享系统提示词里的 f-string 插值
条件拼接的系统提示词每种开关组合是一个不同前缀if flag: system += ...
按用户动态生成工具集工具渲染在位置 0,跨用户零共享tools=build_tools(user)

最后两条在 Agent 里特别常见,因为"按角色给不同工具""按功能开关拼提示词"都是很自然的写法。修法是把动态那部分挪到最后一个断点之后,而不是删掉它。

3.1 会话中途要下指令时,不要改顶层 system

一个具体场景:对话进行到第 20 轮,要切换成简洁模式。直觉写法是改 system —— 那会把 20 轮的缓存全部打掉。

正确做法是把指令作为一条 system 角色的消息追加进 messages

// 无需 beta 头。支持的模型:Claude Opus 5、Opus 4.8、Fable 5、Mythos 5;Sonnet 5 不支持
// 位置约束:必须跟在一条 user 消息之后(或跟在以服务端工具调用结尾的 assistant 消息之后),
// 且必须是 messages 的最后一条、或者后面紧跟一个 assistant 轮;不能是 messages[0]
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 16000,
system: [{ type: "text", text: STABLE_SYSTEM, cache_control: { type: "ephemeral" } }],
messages: [
...history, // 这一整段仍然命中缓存
{ role: "user", content: userMessage },
{ role: "system", content: "简洁模式:回答控制在 40 字以内。" },
],
});

它同时还是更安全的通道:操作方指令走这条路,和用户输入在结构上是分开的,不会和对话内容混在一起。

四、前三篇每一招对缓存做了什么

每一招都在改前缀,区别只在改的位置和频率工具结果裁剪改的是 messages 中段每次触发都失效一次失效点之后全部重写必须配 clear_at_least否则清一点点也破一次思考块裁剪保留时:缓存保住清理时:清理点失效由 keep 参数决定keep 实际上是在缓存与上下文空间间取舍服务端压缩整段历史换成摘要缓存必然重建但触发频率低重建后的前缀短很多后续读取更便宜工具搜索tools 段本身不变加载发生在 messages不动最靠前的前缀对缓存最友好的一招也是最该先做的优先级由此确定:先做工具搜索(不破缓存),再做裁剪(配好 clear_at_least),最后才是压缩(低频但整段重建)。
右边那一格是这张图的结论:工具搜索既省得最多(05 篇:85% 以上),又是唯一不动最靠前那段前缀的手段。在做任何裁剪和压缩之前先把它做掉。

五、破缓存值不值:算一笔账

clear_at_least 这个参数存在的理由,用数字说最清楚。设前缀 80,000 token 已缓存,某次裁剪清掉 X token,之后连续 N 轮复用新前缀:

  • 不裁剪:每轮付 80,000 × 0.1
  • 裁剪:本轮付 (80,000 − X) × 1.25(缓存写入),之后每轮付 (80,000 − X) × 0.1
纵轴=这次裁剪要多少轮之后才把缓存写入的成本赚回来(80K 前缀)172 轮80 轮34 轮12 轮4 轮清 5K清 20K清 40K清 60K曲线极陡:清 5K 要一百多轮才回本(多数会话根本活不到),清 60K 四轮就回本。所以要么清得狠,要么别清。
这是简化模型 —— 忽略了不裁剪时上下文还在继续增长,实际回本会更快一些。但量级关系不变:小额清理几乎一定是亏的。这就是为什么 clear_at_least 的默认值"无"是一个需要主动改掉的设置。

可操作的取值clear_at_least 设成当前前缀的 30% 到 50%。低于这个量级的清理,宁可让上下文再涨一会儿。

六、怎么验证

// 每次请求都看这三个字段,它们是这一层唯一可靠的反馈
console.log(response.usage.cache_read_input_tokens); // 命中读回的(约 0.1 倍价)
console.log(response.usage.cache_creation_input_tokens); // 写入的(约 1.25 倍价)
console.log(response.usage.input_tokens); // 完全没缓存的(1 倍价)

三种异常读法:

现象结论
cache_read 长期为 0,cache_creation 一直有值前缀每次都在变 —— 对照第三节那六类,最常见是时间戳和动态工具集
cache_readcache_creation 都是 0前缀短于最小可缓存长度,断点没生效
cache_read 正常,但账单仍高缓存没问题,问题在别处 —— 多半是压缩迭代没算进来(03 篇那个坑)

需要更细的定位时有一个专门的诊断 beta(cache-diagnosis-2026-04-07):走 client.beta.messages.*,第一轮传 diagnostics: {previous_message_id: null},之后每轮传上一次响应的 id,结果在 response.diagnostics 里。它能直接告诉你前缀是在哪里断开的。

七、排布原则

把缓存约束和02 篇的位置效应合起来,得到一份排布顺序:

位置放什么依据
最前(tools)稳定的工具集;高频工具非延迟,其余延迟加载位置 0,任何变化代价最大
前(system)不随请求变化的角色、约束、输出格式缓存友好 + 位置效应的开头优势
中(历史)对话历史、工具结果 —— 也就是可以被裁剪和压缩的那些中间段本来就最容易被模型忽略,砍在这里损失最小
检索结果、记忆注入每轮都变,放前面会打掉全部缓存
最后本轮用户输入、会话中系统指令位置效应的结尾优势 + 完全不影响缓存

一条经常被违反的推论:记忆和检索结果不要拼进系统提示词。这是很自然的直觉("这是背景信息,当然属于 system"),也是把缓存命中率打到 0 最快的方式。同一条结论在记忆专题 04 篇第五节从另一个方向讲了一遍。

八、小结

  • 缓存按字节前缀匹配,一次失效相当于把那部分按 12.5 倍于命中价重付一次
  • 六类静默失效里,"按用户拼工具集"和"条件拼接系统提示词"在 Agent 里最常见,修法是挪到最后一个断点之后而不是删掉
  • 会话中途下指令要用 messages 里的 system 角色消息,不要改顶层 system
  • 四种手段里工具搜索对缓存最友好且省得最多,应该最先做;压缩虽然必然重建缓存,但频率低且重建后的前缀更短
  • 小额裁剪几乎一定是亏的:从 80K 前缀里清 5K 要一百多轮才回本,clear_at_least 建议设成前缀的 30% 到 50%
  • 每次请求盯 cache_read_input_tokens,长期为 0 就是前缀在变
  • 排布顺序:稳定的在前、可砍的在中、每轮变的在后、本轮输入在最末

下一篇:07 - 观测与落地,怎么知道前面这些改动到底起没起作用。

← 回到 专题索引